跳到主要内容

Robbie Platform 案例脚本学习

给予物体一个力

// 方式一,直接设置物体的方向
rb.velocity = new Vector2(xVelocity * speed, rb.velocity.y);

// 方式二。通过给刚体一个力,第二个参数可以设置给力的模式
rb.AddForce(new Vector2(0f, jumpForce), ForceMode2D.Impulse);

用射线检测

参考资料 RaycastHit2D 参考资料 Physics2D.Raycast

参数名描述
origin射线在 2D 空间中的起点。
direction表示射线方向的向量。
distance光线的最大投射距离。
layerMask过滤器,用于仅在特定层上检测碰撞体。
minDepth仅包括 Z 坐标(深度)大于或等于该值的对象。
maxDepth仅包括 Z 坐标(深度)小于或等于该值的对象。

注意,小心碰撞盒不要与地面交叉了

RaycastHit2D MyRaycast(Vector2 offset, Vector2 rayDirection, float length, LayerMask layer)
{
Vector2 pos = transform.position; //当前位置
Vector2 newPos = pos + offset;
// 在编辑器里绘制射线
Debug.DrawRay(newPos, rayDirection * length, Color.red);
// 这个 RaycastHit2D 能被隐式重载为 bool
return Physics2D.Raycast(newPos, rayDirection, length, layer);
}

移动和跳跃部分

image.png

它有三个射线,下面两个分别是左足右足是否触地,头上那个判断头顶是否有遮挡

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(Rigidbody2D), typeof(BoxCollider2D))]
public class PlayerMovement : MonoBehaviour
{
[HideInInspector]
public Rigidbody2D rb;
[HideInInspector]
public BoxCollider2D coll;


[Header("移动参数")]
public float speed = 8f;
public float crouchSpeedDivisor = 3f;

[Header("跳跃参数")]
[Tooltip("跳跃的基础力")]
public float jumpForce = 7f;
[Tooltip("长按跳跃叠加的力")]
public float jumpHoldForce = 1.9f;
[Tooltip("跳跃持续时间")]
public float jumpHoldDuration = 0.1f;
[Tooltip("蹲伏跳跃时的增量(参考半条命的超级跳,蹲下跳会跳的更高)")]
public float crouchJumpBoost = 2.5f;

[Header("环境检测")]
[Tooltip("左右两个脚的距离(一般为 collider 宽度的一半)")]
public float footOffset = 0.4f;
[Tooltip("头顶的检测距离")]
public float headClearance = 0.5f;
[Tooltip("脚距离地面的距离")]
public float groundDistance = 0.2f;
[Tooltip("当前需要检查的“地面”的 Layer")]
public LayerMask groundLayer;

[Header("玩家状态(Debug 用,请不要设置)")]
public bool isCrouch; // 存储蹲伏的状态
public bool isOnGround; // 是否在地面
public bool isJump; // 是否在跳跃
public bool isHeadBlocked; // 头上是否被挡住了
[HideInInspector]
public bool isMoveLock; // 用来锁住移动,翻转等函数,使之无法操作(主要是预留给悬挂系统使用)


// 临时变量
private float jumpTime; // 持续跳跃的时间
[HideInInspector]
public float xVelocity; // 临时存储移动速度,可以用于判断方向(因为动画系统可能会用到速度,所以这里暴露出来)


// 按键设置
[HideInInspector]
public bool jumpPressed; // 单次按跳跃
[HideInInspector]
public bool crouchPressed; // 单次按下蹲
private bool crouchHeld; // 长按下蹲
private bool jumpHeld; // 长按跳跃


// 站立时的状态
[HideInInspector]
public Vector2 colliderStandSize; // 保存站立时的 BoxCollider2D 大小
[HideInInspector]
public Vector2 colliderStandOffset; // 保存站立时的 BoxCollider2D 位置偏移

// 下蹲时的状态
[HideInInspector]
public Vector2 colliderCrouchSize;
[HideInInspector]
public Vector2 colliderCrouchOffset;

private void Start()
{
rb = GetComponent<Rigidbody2D>();
coll = GetComponent<BoxCollider2D>();
colliderStandSize = coll.size;
colliderStandOffset = coll.offset;

isMoveLock = false; //锁最开始是关闭的

colliderCrouchSize = new Vector2(coll.size.x, coll.size.y / 2);
colliderCrouchOffset = new Vector2(coll.offset.x, coll.offset.y / 2);
}

/// <summary>
/// 一般接收用户输入在这里
/// </summary>
private void Update()
{
if (Input.GetButtonDown("Crouch")) {
crouchPressed = true;
}

// 所有 GetButtonDown 操作的都不能直接这样 jumpPressed = Input.GetButtonDown("Jump"); 写
// 因为Update 和 FixedUpdate 执行次数不同,当 FixedUpdate 判断这个 bool 时他已经被改成 false了
if (Input.GetButtonDown("Jump"))
{
jumpPressed = true;
}

jumpHeld = Input.GetButton("Jump");
crouchHeld = Input.GetButton("Crouch");
}

/// <summary>
/// 物理有关一般在这里
/// </summary>
private void FixedUpdate()
{
PhysicsCheck(); // 射线检查

GroundMovement(); // 移动函数

MidAirMovement(); // 跳跃
}

/// <summary>
/// 跳跃
/// </summary>
private void MidAirMovement()
{
// 如果单次按下跳跃键,还在地面且还没跳跃时,头顶有障碍也不能跳
if (isOnGround && jumpPressed && !isJump && !isHeadBlocked)
{
if (isCrouch)
{
StandUp();
rb.AddForce(new Vector2(0f, crouchJumpBoost), ForceMode2D.Impulse);
}

jumpPressed = false;
isOnGround = false;
isJump = true;
// 用于记录跳跃时长,这里只会在刚按下跳跃时执行,所以这里可以当前时间 + 持续时间来计算出什么时候应该结束跳跃
jumpTime = Time.time + jumpHoldDuration;

// 第一个参数表示给力的方向,第二个参数表示力的模式,这个 ForceMode2D.Impulse 表示突然给力
rb.AddForce(new Vector2(0f, jumpForce), ForceMode2D.Impulse);
}
else if (isJump)
{
if (jumpHeld) // 如果在跳跃的前提下持续跳跃
rb.AddForce(new Vector2(0f, jumpHoldForce), ForceMode2D.Impulse);
if (jumpTime < Time.time) // 时间到了就结束跳跃
isJump = false;
}
else
{
isJump = false;
}
}

/// <summary>
/// 判断是否在地面
/// </summary>
private void PhysicsCheck()
{
//左脚的偏移(左侧是负值)
RaycastHit2D leftCheck = MyRaycast(new Vector2(-footOffset, 0f), Vector2.down, groundDistance, groundLayer);
RaycastHit2D rightCheck = MyRaycast(new Vector2(footOffset, 0f), Vector2.down, groundDistance, groundLayer);
// 判断当前是否在地面(这里使用了隐式转换,RaycastHit2D 转成 bool类型 标识它是否被击中)
isOnGround = leftCheck || rightCheck;

RaycastHit2D headCheck = MyRaycast(new Vector2(0f, coll.size.y), Vector2.up, headClearance, groundLayer);
isHeadBlocked = headCheck;
}

/// <summary>
/// 封装一下这个射线检测方法,免得每次都需要写重复的代码
/// </summary>
/// <param name="offset"></param>
/// <param name="rayDirection"></param>
/// <param name="length"></param>
/// <param name="layer"></param>
/// <returns></returns>
public RaycastHit2D MyRaycast(Vector2 offset, Vector2 rayDirection, float length, LayerMask layer)
{
Vector2 pos = transform.position; //当前位置
Vector2 newPos = pos + offset;

RaycastHit2D hit = Physics2D.Raycast(newPos, rayDirection, length, layer);
Debug.DrawRay(newPos, rayDirection * length, hit ? Color.red : Color.green);
return hit;
}

/// <summary>
/// 用于角色移动
/// </summary>
///
private void GroundMovement()
{
// 如果移动被锁住了,则直接返回
if (isMoveLock) return;

if (crouchHeld && !isCrouch && isOnGround)
Crouch();
// 它的作用就是判断当前已经是下蹲了,不加这个 isCrouch 会导致每一帧都执行这个 StandUp 方法(且不能在头顶有东西时站起来)
else if (!crouchHeld && isCrouch && !isHeadBlocked)
StandUp();
else if (!isOnGround && isCrouch && !isHeadBlocked)
StandUp();

xVelocity = Input.GetAxis("Horizontal");

// 下蹲后速度不同
if (isCrouch) xVelocity /= crouchSpeedDivisor;

rb.velocity = new Vector2(xVelocity * speed, rb.velocity.y);
FlipDirection();
}


/// <summary>
/// 判断当前角色的方向翻转
/// </summary>
private void FlipDirection()
{
// 这里需要使用 Vector3 否则 z 轴可能被置为 0
if (xVelocity < 0) transform.localScale = new Vector3(-1, 1, 1);
if (xVelocity > 0) transform.localScale = new Vector3(1, 1, 1);
}

/// <summary>
/// 蹲伏
/// </summary>
private void Crouch()
{
isCrouch = true;
coll.size = colliderCrouchSize;
coll.offset = colliderCrouchOffset;
}

/// <summary>
/// 站起来
/// </summary>
private void StandUp()
{
isCrouch = false;
coll.size = colliderStandSize;
coll.offset = colliderStandOffset;
}
}

悬挂系统

image.png

这个悬挂系统依赖于上面的移动脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(PlayerMovement))] // 攀爬系统依赖于移动系统
public class PlayerHanging : MonoBehaviour
{
private PlayerMovement pm;

[Header("射线的长度")]
[Tooltip("眼睛的长度")]
public float eyeLength = 1.5f;
[Tooltip("检查上下是否存在墙壁的射线的距离")]
public float reachOffset = 0.6f; // 头顶往上是没有墙壁的,而头顶往下是有墙壁的
[Tooltip("可以攀爬时,离墙的距离")]
public float grabDistance = 0.3f;

[Header("跳跃参数")]
public float hangingJumpForce = 15f; // 挂在墙壁上跳跃的力

[Header("玩家状态")]
public bool isHanging; // 是否在悬挂

// 临时变量
private float jumpTime;


private float playerHeight; // Player 的高度为碰撞体 size 的 y


void Start()
{
pm = GetComponent<PlayerMovement>();
playerHeight = pm.coll.size.y;
}

// Update is called once per frame
void Update()
{
}

private void FixedUpdate()
{
PhysicsCheck();
MidAirMovement();
}

/// <summary>
/// 射线判断
/// </summary>
private void PhysicsCheck()
{
float direction = transform.localScale.x; // 可以通过 localScale 判断方向
Vector2 grabDir = new Vector2(direction, 0f);

// 头顶的射线,用于判断是否刚好够到岩壁(头顶不应该被遮住)
RaycastHit2D blockedCheck = pm.MyRaycast(
new Vector2(pm.footOffset * direction, playerHeight),
grabDir,
grabDistance,
pm.groundLayer);

// 判断前面是否有墙壁
RaycastHit2D wallCheck = pm.MyRaycast(
new Vector2(pm.footOffset * direction, eyeLength),
grabDir,
grabDistance,
pm.groundLayer);

// 判断头顶往上是没有墙壁的,而头顶往下是有墙壁的
RaycastHit2D ledgeCheck = pm.MyRaycast(
new Vector2(reachOffset * direction, playerHeight),
Vector2.down,
grabDistance,
pm.groundLayer);

// !pm.isOnGround 物体不在地上
// pm.rb.velocity.y < 0 是角色开始下落(就是加速度为负的)
// !blockedCheck 表示头顶没有被遮住
if (!pm.isOnGround && pm.rb.velocity.y < 0 && ledgeCheck && wallCheck && !blockedCheck)
{
// 因为默认射线一碰到墙体就停住了,那个位置离实际贴合的距离有点远,这里用来优化挂墙点
Vector3 pos = transform.position;
// 这个 distance可以获得射线距离最近的这个碰撞体之间的这个长度(0.05 避免太贴墙)
pos.x += (wallCheck.distance - 0.05f) * direction;
pos.y -= ledgeCheck.distance;
transform.position = pos;

// 让物体停滞在墙上(模拟抓住岩壁)只需把 Rigidbody2D 的状态设置成静态
pm.rb.bodyType = RigidbodyType2D.Static;
isHanging = true;
pm.isMoveLock = true;
}
}

/// <summary>
/// 跳跃
/// </summary>
private void MidAirMovement()
{
if (isHanging)
{
if (pm.jumpPressed) // 壁挂时跳跃
{
// 先把 Rigidbody2D 的状态调整回动态的
pm.rb.bodyType = RigidbodyType2D.Dynamic;
pm.rb.AddForce(new Vector2(0f, hangingJumpForce), ForceMode2D.Impulse);
isHanging = false;
pm.isMoveLock = false; // 解锁
}
else if (pm.crouchPressed) // 壁挂时下蹲则下落(这里要取第一次按下蹲时,不然跳跃时可能手贱一直按着下蹲导致跳不上去)
{
pm.crouchPressed = false;
// 先把 Rigidbody2D 的状态调整回动态的
pm.rb.bodyType = RigidbodyType2D.Dynamic;
isHanging = false;
pm.isMoveLock = false; // 解锁
}
}
}
}

控制点光源渐变

GIF.gif

public class EmissionPulse : MonoBehaviour
{
public float maxIntensity = 15f; //The max emissive intensity
public float damping = 2f; //The damping to control the pulse speed

Material material; //The material being controlled
int emissionColorProperty; //The ID of the emission property


void Start ()
{
//Get a reference to the Renderer component so we can store the material of it
Renderer renderer = GetComponent<Renderer>();
material = renderer.material;

//Convert the property ID string to an integer. This is much more efficient
//than using strings to control material properties
emissionColorProperty = Shader.PropertyToID("_EmissionColor");
}

void Update()
{
//Calculate the emission value based on Time and intensity
float emission = Mathf.PingPong(Time.time * damping, maxIntensity);

//Convert this to a color value
Color finalColor = Color.white * emission;

//Apply the color to the material
material.SetColor(emissionColorProperty, finalColor);
}
}

角色动画控制

这个依旧依托于上面的移动脚本和攀爬脚本

// 获得父对象的组件
PlayerMovement movement = GetComponentInParent<PlayerMovement>();

然后就可以直接操作动画了

[RequireComponent(typeof(Animator))]
public class PlayerAnimation : MonoBehaviour
{
private Animator anim;
private PlayerMovement pm; // 需要取得父类的 PlayerMovement 脚本
private PlayerHanging ph; // 需要判断物体是否在攀爬

void Start()
{
anim = GetComponent<Animator>();
pm = GetComponentInParent<PlayerMovement>();
ph = GetComponentInParent<PlayerHanging>();
}

void Update()
{
// 因为动画的 speed 只需判断速度是否大于 0,所以这里取绝对值
anim.SetFloat("speed", Mathf.Abs(pm.xVelocity));
anim.SetBool("isCrouching", pm.isCrouch);
anim.SetBool("isHanging", ph.isHanging);
anim.SetBool("isJumping", pm.isJump);
anim.SetBool("isOnGround", pm.isOnGround);
// anim.SetBool("verticalVelocity", true);
}
}

这里再介绍一种通过数字编号赋值的方式,上面那种每次都要传递字符串的方式在移动端很容易出错

...
private int groundId;
private int hangingId;
private int speedId;
private int jumpId;
private int crouchId;


void Start()
{
...
groundId = Animator.StringToHash("isOnGround");
hangingId = Animator.StringToHash("isHanging");
speedId = Animator.StringToHash("speed");
jumpId = Animator.StringToHash("isJumping");
crouchId = Animator.StringToHash("isCrouching");
}

void Update()
{
anim.SetFloat(speedId, Mathf.Abs(pm.xVelocity));
anim.SetBool(crouchId, pm.isCrouch);
anim.SetBool(hangingId, ph.isHanging);
anim.SetBool(jumpId, pm.isJump);
anim.SetBool(groundId, pm.isOnGround);
// anim.SetBool("verticalVelocity", true);
}

音频管理实例

单独创建一个 AudioManager 对象,把这个脚本挂载在它的身上,这里的主要知识点就是 DontDestroyOnLoad 这个 API 的使用,具体看其它教程

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class AudioManager : MonoBehaviour
{
/// <summary>
/// 这里使用 static 来让这个脚本成为单例
/// 配合下面的 DontDestroyOnLoad 使用
///
/// 这里采用单例是为了不管场景怎么变,这里都采用同一个音频管理实例
/// 因为哪个场景都需要这玩意,如果每次都加载就太浪费资源了
/// </summary>
private static AudioManager _current;

[Header("环境声")]
public AudioClip ambientClip;
public AudioClip musicClip;

[Header("FX声")]
public AudioClip deathFxClip;

[Header("角色声效")]
public AudioClip[] walkStepClips;
public AudioClip[] crouchStepClips;
public AudioClip jumpClip;
public AudioClip deathClip;

public AudioClip jumpVoiceClip;
public AudioClip deathVoiceClip;

// 这里因为每种音频都需要同时播放,所以对应的也需要这么多 AudioSource
private AudioSource ambientSource; // 环境音效
private AudioSource musicSource; // 背景音乐
private AudioSource fxSource; // 特效用的音乐
private AudioSource playerSource; // 角色发出的声音,如走路声之类的
private AudioSource voiceSource; // 人声

/// <summary>
/// 这个 Awake 会在一开始就调用
/// </summary>
private void Awake()
{
if (_current != null)
{
Destroy(gameObject);
return;
}
else
{
_current = this;
DontDestroyOnLoad(this);
}

// 动态的给当前对象添加上组件
ambientSource = gameObject.AddComponent<AudioSource>();
musicSource = gameObject.AddComponent<AudioSource>();
fxSource = gameObject.AddComponent<AudioSource>();
playerSource = gameObject.AddComponent<AudioSource>();
voiceSource = gameObject.AddComponent<AudioSource>();

StartLevelAudio();
}

/// <summary>
/// 游戏一开始就播放的音乐,环境声,背景音乐
/// </summary>
void StartLevelAudio()
{
_current.ambientSource.clip = _current.ambientClip;
_current.ambientSource.loop = true; // 让音乐循环播放
_current.ambientSource.Play();

_current.musicSource.clip = _current.musicClip;
_current.musicSource.loop = true;
_current.musicSource.Play();
}

/// <summary>
/// 播放脚步的声音
/// </summary>
public static void PlayFootstepAudio()
{
// 获取随机下标(这个返回的是 "[ min , max )" 所以不用数组减一)
int index = Random.Range(0, _current.walkStepClips.Length);
_current.playerSource.clip = _current.walkStepClips[index]; // 随机播放脚本数组里面的声音
_current.playerSource.Play();
}

/// <summary>
/// 播放脚步的声音
/// </summary>
public static void PlayCrouchFootstepAudio()
{
// 获取随机下标(这个返回的是 "[ min , max )" 所以不用数组减一)
int index = Random.Range(0, _current.crouchStepClips.Length);
_current.playerSource.clip = _current.crouchStepClips[index];
_current.playerSource.Play();
}

/// <summary>
/// 播放跳跃的声音
/// </summary>
public static void PlayJumpAudio()
{
_current.playerSource.clip = _current.jumpClip;
_current.playerSource.Play();

// 播放跳跃的人声
_current.voiceSource.clip = _current.jumpVoiceClip;
_current.voiceSource.Play();
}

/// <summary>
/// 角色死亡时播放的声音
/// </summary>
public static void PlayDeathAudio()
{
_current.playerSource.clip = _current.deathClip;
_current.playerSource.Play();

_current.voiceSource.clip = _current.deathVoiceClip;
_current.voiceSource.Play();

_current.fxSource.clip = _current.deathFxClip;
_current.fxSource.Play();
}
}

判断角色死亡

关键就是这个 OnTriggerEnter2D

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class PlayerHealth : MonoBehaviour
{
public GameObject deathVFXPrefab; // 特效对象

private int trapsLayer;

private void Start()
{
// 就像动画参数需要获取编号一样,这个 Layer 也需要图层编号
trapsLayer = LayerMask.NameToLayer("Traps");
}

private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.gameObject.layer == trapsLayer)
{
// 角色死亡时在角色位置生成一个死亡特效
Instantiate(deathVFXPrefab, transform.position, transform.rotation);
gameObject.SetActive(false); // 再隐藏游戏角色的启用
AudioManager.PlayDeathAudio();

// 使用 GameManager 来管理
// SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
}
}

收集宝珠

同上

public class Orb : MonoBehaviour
{
private int player; // 用来记录图层的编号

public GameObject explosionVfxPrefab; // 特效对象

void Start()
{
player = LayerMask.NameToLayer("Player");
}

private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.gameObject.layer == player)
{
// 实例化特效
Instantiate(explosionVfxPrefab, transform.position, transform.rotation);
gameObject.SetActive(false);
AudioManager.PlayOrbAudio();
}
}
}

GameManager

创建一个叫做 GameManager 的脚本(名字不能错)

TODO: https://www.bilibili.com/video/BV1iE411q7gU